Skip to content

fix(tui-v2): make Ctrl+S stash synchronous + harden streaming bridge#622

Open
CacinieP wants to merge 746 commits into
lsdefine:mainfrom
CacinieP:fix/tui-v2-stash-sync-and-stream-guards
Open

fix(tui-v2): make Ctrl+S stash synchronous + harden streaming bridge#622
CacinieP wants to merge 746 commits into
lsdefine:mainfrom
CacinieP:fix/tui-v2-stash-sync-and-stream-guards

Conversation

@CacinieP

Copy link
Copy Markdown

背景

多会话(部分 running、部分 idle)+ 每个会话超长上下文时,在某会话按 Ctrl+S 暂存草稿,偶发导致整个 tuiapp_v2 彻底卡死。此前已有多次针对该路径的修复(24255c1 use TextArea.clear、e38f233 _stash_cleanup_restore 走编辑管线等),都集中在 InputArea/Document 层,但仍偶发复现。

调查过程(诚实交代)

我没有稳定复现这个“彻底卡死”,也不假装已根除它。以下是实际做了的事:

  • 通读 stash 全链路(action_stash_stash_cleanup_clear/restoreon_text_area_changed_resize_input)和流式渲染链路(_consume_display_queuecall_from_thread_on_stream_update_assistant)。
  • 审查 tuiapp_v2 历史中所有 stash 相关 commit,确认前几次修复都在治 InputArea/Document 重建这一表因。
  • 在真实 textual 8.2.7(pyproject 要求 >=0.70)中验证关键 API 语义:call_from_threadFuture.result() 是同步阻塞;call_after_refresh 投递 InvokeLater,最终由 Screen 的 idle/timer 回调队列 flush。
  • 构造多会话持续流式 + 超长上下文(120 条已渲染 assistant 消息)的最小复现,测量:
    • action_stash 本体 0.2ms,_stash_cleanup_clear 0.5ms,5 万字符草稿 clear 0.7ms。
    • 主循环在流式下健康(call_later 探针 ~100ms 响应;call_after_refresh 回调即使在 3 线程 × 5ms 高频 dirty 下也 ≤17ms 执行)。
    • Pilot 的 press("ctrl+s") 在流式下显示 ~2s,但这不是真实卡顿——是 Pilot._wait_for_screen 在等所有 widget 消息队列排空,而流式让队列持续非空。绕过 pilot.press 直接调 action_stash 后,stash 在亚毫秒内完成。

排除的假设:终端 XON/XOFF 流控(textual 的 linux_driver 已禁用 IXON/IXOFF)、styles.height 触发的全屏 relayout(实测 <1ms)、Document 重建(clear 路径已不走)、主循环被流式渲染饿死(探针证伪)。

仍存在并被本 PR 修复的真实缺陷

尽管无法稳定复现“彻底卡死”,调查中确认了 stash/streaming 路径上几个真实缺陷,它们在极端竞态下正是最可能的诱因:

1. Ctrl+S 的清理依赖异步回调(本 PR 核心)

action_stash 通过 self.app.call_after_refresh(_stash_cleanup_clear/restore) 推迟可见的清空/恢复。该回调进入 Screen 的 idle 回调队列,而该队列的 flush 以“屏幕 layout/repaint 稳定”为前提。多个超长上下文会话持续流式时,屏幕几乎持续处于 dirty 状态,回调可能被长时间推迟——输入框不清空、_draft_stash 已翻转,状态不一致,表现为“卡住直到流式停歇”。

而当初推迟的唯一理由(reset() 重建 Document 卡 UI)已不成立:reset() 现走 TextArea.clear()(编辑管线,无 Document 重建),整条清理实测 <1ms。

修复action_stash 改为同步执行清理。按键事件返回前,buffer 与 stash 标志已一致,不再依赖流式空闲。

2. _on_stream 引用未定义的 refresh_chrome(NameError)

exit-boundary replay 分支里 if refresh_chrome: 中的 refresh_chrome 是未定义名,抛 NameError。异常经 call_from_thread 重抛,导致 done 事件永远不落地,spinner 永转。

修复:该分支本就该刷新 chrome(与主路径一致),去掉未定义判断,无条件刷新。

3. _consume_display_queuecall_from_thread 未保护

回调抛异常时 Future.result() 重抛,杀死 consume 线程,done 不落地,spinner 卡死。

修复:抽 _call_stream 包一层,让 consumer 存活以便 done 仍能 settle 消息。

验证

Pilot 最小用例(120 条超长历史 + 2 个后台持续流式会话,切回超长会话操作 stash):

场景 结果
clear path(有文本) 同步完成,输入清空,_draft_stash 置位
restore path(空输入+有 stash) 1.6ms,文本恢复,_draft_stash 清空
空输入无 stash(noop) 不崩溃
5 万字符草稿 clear 0.7ms
流式期间 app 存活 is_running=True,会话仍在 streaming

无回归。语法 ast.parse 通过。

范围

frontends/tuiapp_v2.py,未触碰 GA 核心代码(agentmain/ga.py/llmcore/agent_loop 等)。

诚实声明

本 PR 不能宣称彻底修好“彻底卡死”——因为该症状在 Pilot 下未能稳定复现(看到的 ~2s 是 Pilot 自身等待语义的 artifact)。本 PR 修掉的是调查中确认存在的真实缺陷,其中第 1 项消除了最可疑的诱因(stash 路径在流式负载下对异步回调调度的依赖)。若复现仍在,建议下次复现时用 py-spy dump 抓主线程栈,重点看是否卡在 _invoke_and_clear_callbacks / _on_idle 之外。

lsdefine and others added 30 commits May 15, 2026 19:03
* feat(tui): improve message scrolling and rewind picker

* feat(tui): add double-escape rewind shortcut
Squashed PR lsdefine#393: update README.md and add installation docs.
…e support

- app.js: triple-guard for CJK IME composition (compositionstart/end + keyCode 229)
- lib.rs + Cargo.toml: add tauri-plugin-single-instance to prevent zombie processes
- tauri.conf.json: add "dmg" target and png icons for macOS bundling
# Conflicts:
#	frontends/desktop/src-tauri/Cargo.lock
#	frontends/desktop/src-tauri/src/lib.rs
… tips

Adds to frontends/tuiapp_v2.py:
- /rename <name>: rename current session; collision-checked across in-memory
  sessions and the persistent registry. Topbar and sidebar refresh in place.
- /continue <name>: resume by stored name in addition to the existing index
  form (/continue 1). The picker (/continue with no arg) labels each entry
  with its stored name when available.
- /cost [all]: per-session token usage (input / output / cache_create /
  cache_read), elapsed time, request count, and context-window %-left. The
  "all" form lists every tracked thread, including ended sessions.
- ask_user picker upgrades: free-text escape hatch on every list, Esc-back
  navigation, single/multi-select switch from a [多选]/[multi] question hint,
  candidate sanitizer that handles JSON-envelope debris.
- Runtime tip footer (`└ Tip: ...`) that rotates short usage hints.
- Topbar status chip showing the active model + idle/busy state.
- Cross-platform launcher hardening: auto-install of rich/textual on first
  run, and a hint when running under git-bash/mintty.

New modules:
- frontends/session_names.py: JSON sidecar at temp/model_responses/
  session_names.json keyed by log-file basename; touched only by /rename and
  /continue <name>. continue_cmd is not modified.
- frontends/cost_tracker.py: per-thread TokenStats, captured via
  monkey-patches on llmcore._record_usage and llmcore.print (Anthropic SSE's
  final output count only arrives via the latter). Idempotent install().

No core files touched.
…ine#397)

feat(tui): /rename, /cost, /continue <name>, ask_user picker, runtime…
Adds Ctrl+T theme cycling across six palettes: a custom ga-default
matching the existing github-dark scheme plus Textual built-ins
(nord, gruvbox, dracula, tokyo-night, textual-light). Themes flow
through Textual's native register_theme + get_css_variables, so CSS
references $ga-bg / $ga-fg / etc. that resolve from the active theme;
ga-default uses our exact palette while built-ins derive the missing
slots (border/dim/muted/border_hi) from resolved bg/fg via a blend.

watch_theme keeps the C_* module globals (Rich Text colors) and the
role-color dict in sync with the active theme, invalidates message
caches, and remounts the current session. Rich Markdown rendering
also passes a per-theme RichTheme so code blocks, links, headings,
and quotes follow the active palette instead of Rich's frozen
defaults. text-muted / text-disabled are deliberately not inherited
from Textual: those resolve to 'auto NN%', a Textual-only syntax
Rich Text rejects.
…sdefine#399)

Rephrased docstring in cost_tracker.py to clarify API-mode tracking semantics.
Display a concise startup banner with model name when running in interactive TTY mode.
The five topbar chips (session name / model / effort / tasks / clock)
were hardcoded github-dark hex values introduced in the upstream
topbar redesign, so they stayed cyan/amber/lavender on every theme.
Add five chip_* slots to _DEFAULT_PALETTE and derive them for
built-in themes from primary / secondary / warning / accent / success,
keeping five distinguishable hues across themes. watch_theme syncs
the C_CHIP_* globals on switch.

Also move the '▾ fold' indicator from the LEFT column to the RIGHT,
just before the clock. The left column packs identity + session +
running status pill into a ratio=1 ellipsis column, so a long status
('running 2m 15s') was eating the trailing fold glyph as '...'.
The clock chip was github-dark's brighter success green (#56d364),
which read as more vibrant than the rest of the muted palette and
clashed with C_GREEN. Use the same #7ec27e the sidebar uses for the
active session marker, so all 'positive accent' greens in the chrome
sit at one saturation level.
These are agent-internal metadata: <summary> already feeds the sidebar
preview and the fold title, <thinking> is meant to be hidden. They were
leaking into the chat body, and worse, CommonMark parses
'<summary>X</summary>\n<body>' (no blank line between) as a single HTML
block that swallows the following body line — so the model's actual
reply disappeared from view whenever it wrote the summary tag immediately
before its answer with only a single newline.

Strip both tags in _render_md before passing to the Markdown parser.
Rich's divide_line treats a Chinese run as one indivisible word and
bumps it whole to the next line when it doesn't fit the remaining
space, wasting cells like 'AI ↩ 助手...'. Patch both rich._wrap and
textual.content so Text.wrap (assistant Markdown) and Visual.to_strips
(user messages) pack leading CJK chars into the current line's
remainder, then fold the rest at full width.

Non-CJK words keep stock behavior; code-block content remains
untouched because the patch only adjusts wrap break positions.
_render_md now produces two renders: the existing narrow ANSI roundtrip
for display (mouse selection still anchors via segment.style.meta) and a
wide render at width=10000 that contains only structural \n. An alignment
walk maps each visual line back to a position in the wide source, so
SelectableStatic.get_selection extracts source text without wrap-induced
newlines.

The aligner handles three layouts:
- 1:1 (no wrap): narrow line maps directly to wide line
- N:1 wrap: hanging indent on continuation lines is stripped, and the
  whitespace eaten at wrap points (e.g. spaces between English words) is
  recovered from the wide line
- centered single-line (Markdown headers): wide and narrow have different
  leading-pad widths; source uses the lstripped content, and narrow's pad
  becomes per-line indent that selection clamps over
The two CJK-aware drop-ins (divide_line for Rich/textual.content, and
compute_wrap_offsets for textual._wrap/_wrapped_document) shared
identical greedy-pack-then-fold core logic, written twice. Extract into
_fold_chunk_cells so each call site only handles interface shape (Rich
words vs Textual chunk regex, tab handling).

Also extend the patch to cover textual._wrap.compute_wrap_offsets and
textual.document._wrapped_document — the TextArea / Static-via-Content
paths use compute_wrap_offsets, not divide_line, so the prior patch
missed input-box and user-bubble wrap. Drop try/except defensiveness
around the Textual patch (we target latest Textual; let real breakage
surface), split _CJK_WRAP_RE into commented sub-ranges, prune WHAT
comments.

Verified: non-CJK output remains byte-equivalent to upstream across
both divide_line and compute_wrap_offsets fixture sets; live render at
width 60 packs the first line to ~98% utilization on input box, USER
bubble, and AGENT bubble.
…utput

extract_ui_messages now renders tool_use headers and tool_result fences
into the assistant content using the same string format that agent_loop
yields live, so fold_turns folds restored sessions identically to live
chat. Tool-only response rounds no longer drop the user message, and
Turn 1 also carries a marker so the first turn folds like the rest.
_user_text was misclassifying any prompt whose text block didn't start
with '### [WORKING MEMORY]' as a real user message — but auto-continue
prompts can interleave [SYSTEM TIPS], [System] regenerate triggers,
[DANGER] guard injections, and similar. Treat any prompt carrying a
tool_result block as auto-continue (the natural signal that this is
the next round of an in-flight LLM call), and extend the injection
marker list as a secondary guard for the rare tool_result-less
synthetic prompts.
nianyucatfish and others added 29 commits June 11, 2026 09:19
tuiapp_v2 的两项增强,拆成两个 commit。

## 1. 工具显示卡片(file_write / file_patch / file_read / code_run)
把 agent_loop verbose 的 `🛠️ Tool` 块替换为紧凑卡片:
- **file_write / file_patch**:行号 + 上下文的主题化 diff;append/prepend 走整文件 diff 拿真实上下文。
- **file_read**:读到的内容带行号 gutter,剥离 LLM-facing 噪音(`[FILE]`/show_linenos 前言/截断提示),超长折叠;行号在不可知处(keyword 窗口 / 挖洞后)留空。
- **code_run**:参考 Claude Code / Codex 的 exec 渲染,无边框、`│` 命令 gutter + `└` 输出 gutter,输出 dim、**保尾截断**(traceback 关键在末尾);代码来自正文 ```块``` 时不重复展示。
- 三卡共用 `_CardWriter` / `_card_status_row` / `_emit_gutter`;2 格左 margin 只进显示流、不进复制源。
- capture 机制:live 走 `tool_before/after` 钩子,`/continue` 走 `iter_write_captures` 从历史 tool_result 重建,按 `hash(get_pretty_json(args))` 内容寻址。
- 双流渲染对齐(`_align_md_renders`)支持 margin 映射,鼠标复制不含视觉缩进。

## 2. /model 与 /effort 渠道内运行时切换
- **/model**:在线拉模型列表的 searchable picker;搜索框兼作自定义模型名输入(无匹配时 Enter 直设)。
- **/effort**:reasoning effort 档位切换,协议差异在 `effort_note` 单点编码——Claude(`output_config.effort`) low/med/high 原样、xhigh→max、none/minimal 忽略;OpenAI(`reasoning_effort` / `reasoning.effort`) 透传;mixin 经 `_BROADCAST_ATTRS` 广播到全部子渠道。
- 逻辑收在 `frontends/model_cmd.py`(agent 无关、可单测)。

## 测试
- 卡片:两份真实会话日志全链路重放,51 个 code_run + 23 read + 20 write 全部渲染、双流 parity、复制不含 margin。
- /model /effort:拦截真实 llmcore 请求构造验证 effort 进 payload(含 mixin 广播、各协议字段);picker 自定义输入走 Textual pilot 端到端。
…ine#599)

* feat(tui): /continue 恢复 plan 卡片 + Ctrl+S/Ctrl+C 路由修复

plan-card 恢复:plan 模式判定改用日志中结构化的 enter_plan_mode
tool_use(continue_cmd.find_plan_entry),不再扫聊天文本——用户打字
伪造不出。is_active/resolve_path 新增 restored_path 信号,AgentSession
新增 restored_plan_path 字段,plan_scan_baseline 退化为仅供 current_step
的 📌 扫描用。

Ctrl+S restore 卡死:_stash_cleanup_restore 由 self.text= 改为
clear()+_insert_via_keyboard,走编辑管线避免长会话全屏 relayout,与
stash 清空路径对称。

Ctrl+C 弹回收紧:_cmd_stop 仅在该轮未被消费(agent 尚未产出回复)时
才把 user 文本弹回输入框,避免已答轮次污染输入框 / 重发导致历史重复。

* feat(tui): plan 卡片可滚动(4 行任务窗口)+ TODO 渲染为 [ ]/[x]

planbar 拆为固定表头/步骤行(#planbar-head)+ 任务滚动区
(#planbar-tasks,max-height:4)。最多露 4 个 TODO,其余滚轮/PageUp
翻看,去掉旧的 5 行预算与 ⋮ +N more 截断;表头与当前步骤行固定不滚。

任务标记 ☐/✔ → [ ]/[x],与 plan.md 源文件写法对齐。
The tool-cards change (lsdefine#593) tightened _md_line_has_box_drawing to exempt
pure-horizontal '─' runs as markdown hr. But a SIMPLE-box Markdown table's
only box glyph is its header rule, so that exemption dropped whole tables
out of the passthrough copy path and into the wide-render aligner, which
maps selection cell-columns to character indices — misaligning copy of any
table with CJK (wide) cells.

Remove the pure-horizontal exemption so a table's header rule is detected
again and the table run stays passthrough. Card gutters (└─ / │ / └) keep
their own prefix-based exemptions, so tool cards are unaffected. The only
cost is a real markdown hr copying as a dash run — the pre-lsdefine#593 behavior.
…owser (lsdefine#598)

web driver init nudges the browser by opening a page when no session
appears; os.startfile raises AttributeError on Linux/macOS, killing
the retry loop on headless servers. webbrowser.open is stdlib and
cross-platform with the same effect.
修复个人微信前端在 headless 容器内无法登录
* feat(tui): workspace 项目模式 + @ 文件引用补全(v2/v3)

- workspace: /workspace 设/off/picker、junction 复用 project_mode 记忆、
  per-session(v2)/进程级(v3)、/continue 恢复、session map 持久化绑定状态
- @ 补全(completion-only): 子序列模糊 + path-like 目录补全(~/ / ./ C:\)、
  提交期 @路径作为普通文本交 agent
- v3 filterable 焦点链 picker(输入框作焦点环对象、free_input)
- 新增共享模块 at_complete.py / workspace_cmd.py;核心零改动

* feat(tui): @ 路径绝对化 + 默认根=temp + 未绑显示完整路径

- 默认根 os.getcwd() → agent 工作目录 <GA根>/temp(与 file_read/code_run 一致、不随启动 cwd 飘)
- 提交期 @相对 → @绝对(display 保留相对短路径),让 agent file_read(相对自身 cwd)找得到
- 未绑 workspace 时候选显示完整路径(根不直观);索引忽略 model_responses 会话日志噪音
- 启动预热 temp 索引 + candidates_for 惰性兜底(任何根首次访问自动建)
…lsdefine#606)

- macljqCtrl.py: macOS implementation of mouse/keyboard/screenshot/window
  enumeration mirroring ljqCtrl API, plus AX accessibility control tree
  (AXElements/AXFind/AXPress) as the macOS equivalent of UIA
- computer_use.md: add section 3 documenting the macOS platform branch
- .gitignore: whitelist memory/macljqCtrl.py
… prevent UI freeze

ESC cancel sent session/cancel RPC but never cleared the busy flag, leaving the UI stuck if the server-side cancelled event never arrived. Also clear busy on all sessions when the bridge closes so pending poll loops exit cleanly.

Co-authored-by: dilong888 <dilong888@users.noreply.github.com>
v3 的 _do_restore 此前只把历史载入内存 backend.history,不碰当前进程日志。native 后端逐轮只把新增消息 append 到 model_responses_{pid}.txt,恢复出的旧历史从不写入当前日志——续聊只追加增量,下次 /continue 扫到的本进程文件就只剩续聊部分,旧历史丢失。

对齐 v2 _do_continue_restore:恢复前 reset_conversation(快照+清空当前日志),恢复后 copyfile(源日志 → 当前日志),续聊便接在完整历史之后。
…d, AXClick)Feat/macos ax enhancements (lsdefine#611)

* feat(memory): enhance macOS AX control (resolve_pid fix, enabled field, AXClick)

- _resolve_pid: fix bundle_id branch (used un-imported AppKit), 3-tier match (bundle id > app name exact > substring)
- AXElements: collect 'enabled' field (SOP: check disabled before click)
- AXClick: AXPress-first then fallback to physical-coord Click, honest success判定 via pixel diff
- AXFind: add enabled_only filter, refactor if-forest into _hit helper

* docs(ljqCtrl): tighten macOS AX guidance, drop boilerplate & app-specific detail

- collapse 5-line cross-platform import boilerplate to one line
- generalize control-identifier tip (remove app-specific identifiers)
Annotate config block to guide new GA instances: enumerate candidate
mykey.py variable names and experimentally probe which config works;
only print var/field names, model, apibase host/path, status codes and
error types (never full dict or apikey/token); include a config format
example. Also flag that apibase/endpoint may differ per provider/proxy
at the two request-building sites.
…conflict) (lsdefine#617)

/continue N 默认改为「原地续」:接管原会话日志、后续轮次写回同一文件,取代旧的
镜像复制(supersedes b4356c7 的 mirror 方案),不再每次续接都 fork 新会话、日志增殖。

- 空闲会话 → 直接原地接管;被活进程占用 → 弹窗确认后才复制一份续。
- 每会话出生持锁(temp/model_responses/.locks/<logid>.lock),整进程共用一个心跳
  线程每 5s touch mtime、30s 无心跳判死可接管;atexit 干净释放,崩溃/强杀靠超时兜底。
- 切走/新对话不再「快照+清空」(原 _snapshot_current_log 因 logid≠pid 实为死代码,
  从未生成过快照),旧日志原样留作空闲会话,新对话铸新 logid。
- list_sessions 新增 exclude_log,修正 exclude_pid=getpid 失效导致当前会话出现在
  自己列表里的问题。
- continue_cmd 仅新增函数,reset_conversation/restore/handle 未改动 → 其他前端
  (IM/qt/streamlit 等)行为完全不变。
- 改动仅限 continue_cmd.py + tui_v3.py + tuiapp_v2.py;rewind/worldline 逻辑不在本 PR。
Update WeChat group 21 QR code image.

Co-authored-by: AspasZhang <AspasZhang@users.noreply.github.com>
…ne#621)

In-place /continue retargets agent.log_path to the restored file, so the
reset bind `_bind_workspace(None)` ran during restore was persisting
session_ws_set(path, "") — erasing that session's own workspace mapping to
"" (read back as "explicitly off") before we read it, which also short-
circuited the log-scan fallback. The continued session never re-entered its
workspace.

Add a `persist` flag to `_bind_workspace`; the restore-time reset passes
persist=False so it only refreshes in-memory state. The session→workspace
map is now written solely by explicit /workspace, /workspace off, and a
successful restore.
Ctrl+S (stash draft) cleared/restored the input via call_after_refresh,
deferring the visible state change to the Screen idle callback queue.
That queue is only flushed when the screen is layout/repaint-stable, so
under heavy streaming (multiple long-context sessions producing a near-
continuous stream of dirty regions) the deferred clear/restore could be
postponed long enough that the input never visibly updates while the
half-flipped _draft_stash flag leaves the box in an inconsistent state —
perceived as a freeze until the streaming settles.

The deferral only existed to keep the keystroke snappy back when reset()
rebuilt the TextArea document; reset() now routes through TextArea.clear()
(edit pipeline, no document rebuild), and the full clear/restore + resize
measures well under 1ms even on very long sessions. Running the cleanup
inline makes the keystroke authoritative: by the time the Key event
returns the buffer and stash flag are already consistent, independent of
whatever the streaming loop is doing.

Two related streaming-bridge defects found while reproducing:

- _on_stream referenced an undefined refresh_chrome in the exit-boundary
  replay branch, raising NameError; the exception propagated out of
  call_from_thread, so the done event never settled and the spinner spun
  forever. Unconditional chrome refresh now matches the main path.

- _consume_display_queue called call_from_thread directly; a raised
  callback re-raised via Future.result() and crashed the consume thread
  mid-task, again stranding the spinner. Wrapped in _call_stream so the
  consumer survives and done can still land.

Caveat: the user-reported complete freeze was not stably reproducible in
Textual Pilot under multi-session long-context streaming (the ~2s seen
there is Pilot._wait_for_screen waiting for widget queues to drain, not a
real main-loop stall). These changes remove the most plausible trigger —
the stash path dependency on async callback scheduling under load — but
cannot be claimed to definitively fix a symptom that did not reproduce.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.